test(portal): close v0.2.0 audit coverage gaps (23 new tests)#102
Merged
Conversation
Adds the test coverage that was deferred during the rapid B1 portal merge sequence. Brings the portal stack from 'core acceptance paths only' to 'every security-load-bearing branch covered'. PortalEndpointsControllerTests (+5): - Empty capabilities claim must yield 403 (silent regression guard against a future refactor that treats missing as wildcard). - Cross-tenant DELETE / enable / disable / test / attempts all return 404 PORTAL_NOT_FOUND. Previously only GET and PUT were guarded; every other route was its own untested CAS surface. - Test route additionally asserts the EndpointTester fake never gets invoked, so the 404 precedes any outbound dispatch. PortalCorsMiddlewareTests (new, +7): - Preflight with allowed / disallowed / subdomain-spoofed origin. - RFC 6454 case-insensitive host match. - Missing Origin falls through to next() with no CORS interference. - Real-request CORS-header echo (allowed) and non-echo (disallowed). - Stands up the production middleware ordering (PortalTokenAuth → PortalCors) in a minimal pipeline. PortalLookupCacheTests (new, +5): - Portal-not-enabled returns null. - Cache hit survives DB mutation (proves cache is in the loop). - InvalidateApplication forces DB reload on next GetAsync. - 64-way concurrent InvalidateApplication doesn't throw ObjectDisposedException (regression for the audit fix where Set used to GetOrAdd-reuse a CTS). PortalOriginsAllowlistE2ETests (new, +7, Testcontainers): - Exercises AnyAllowsPortalOriginAsync against real PostgreSQL JSONB. - Exact match, RFC 6454 case-insensitive, portal-disabled apps excluded, no-match, empty array, blank-origin Theory. - Documents in-line why the malformed-JSON catch isn't reachable through the EF write surface (PostgreSQL rejects invalid JSON at INSERT into JSONB).
4 tasks
voyvodka
added a commit
that referenced
this pull request
May 11, 2026
…it doc-drift) (#103) Closes the documentation half of the v0.2.0 portal audit. Tur 1 (#101) was the security fix, Tur 2 (#102) was the test coverage; this PR is the doc drift the same audit surfaced. docs/API.md §3.8 — Portal API (Customer-Facing JWT): - HS256 JWT contract: algorithm pin, signing key per-app, lifetime cap, clock skew, token size cap, required + optional claims. - Capability table: endpoints:read|write|test, attempts:read. - Per-app CORS rules: no wildcards, https-only, RFC 6454 case- insensitive matching, preflight semantics. - Rate limit: shares send-by-appid partition; cross-tenant lookups return 404 (never 403, which would leak existence). - All 10 portal routes documented with request/response shape. - 5 dashboard portal-admin routes documented. - Portal-specific error code table. - End-to-end probe with jose (Node.js mint) + cURL. docs/ARCHITECTURE.md §4.3 — Portal Token Authentication: - Per-application secrets stored on Application (PortalSigningKey, AllowedPortalOriginsJson, PortalRotatedAt). - Pipeline ordering with the three invariants it encodes (ApiKeyAuth bypass, PortalToken-before-PortalCors, both-before-RateLimiter). - PortalLookupCache: TTL, instant local invalidation, atomic CTS swap. - Cross-tenant isolation via 2-arg GetByIdAsync. - JWT validator defense-in-depth (HS256 pin, 8 KiB token cap, MapInboundClaims=false, lifetime cap, opaque error bodies).
4 tasks
voyvodka
added a commit
that referenced
this pull request
May 11, 2026
…alidator merge, PUT→PATCH, disable preserves origins) (#104) Closes the four P1 behaviour findings from the v0.2.0 portal audit. Tur 1 (#101) shipped the P0 security fixes, Tur 2 (#102) shipped the test coverage, Tur 3 (#103) shipped the docs; this Tur 4 closes the behaviour delta. 1. PortalCorsMiddleware deny-cache. HandlePreflightAsync now caches both allow and deny outcomes via IMemoryCache for the same TTL as the per-app signing-key lookup (default 60s). Browsers don't cache rejected preflights, so every OPTIONS from a disallowed origin used to re-scan the portal-enabled app set + deserialize the JSON allowlist — a free DB hammer vector for any caller that knew the portal prefix. Cache key is lowercased-origin scoped (RFC 6454 §4 case-insensitive). New test Preflight_Deny_Decision_Is_Cached_Within_Ttl pins the behaviour by mutating the DB to allow the origin after a 403 and asserting the second call still 403s within the TTL window. 2. EndpointValidationRules helper consolidation. New extension methods (EndpointUrlSyntax, EndpointDescription, EndpointTransformExpression, EndpointCustomHeaders, EndpointAllowedIpsCidrs, EndpointSecretOverride) become the single source of truth for the field-shape rules shared between the 4 admin endpoint validators and the 2 portal endpoint validators. Without this consolidation, tightening a rule on one surface silently leaves the other surface weaker — exactly the drift pattern the audit flagged. Async DNS host-safety check stays in each validator (DependentRules + CustomAsync needs the full property selector). Behaviour unchanged. 3. PortalEndpointsController.Update → [HttpPatch]. The action's body semantics were always partial-replace (every field optional, only non-null fields applied) — that's PATCH, not PUT. Switching the verb aligns the wire surface with reality and stops misleading REST consumers that expect PUT to be full-replace. Route, request shape, response shape unchanged. Two existing tests ported from PutAsJsonAsync to PatchAsync + JsonContent.Create. 4. DashboardPortalController.Disable preserves origins. Removed the line that nulled AllowedPortalOriginsJson on disable. Disable now revokes only the auth surface (PortalSigningKey, PortalRotatedAt) and keeps the operator-curated CORS allowlist so re-enable doesn't force re-curation. Explicit clear path remains: PUT /portal/origins with {origins: []}. Renamed test Disable_Clears_SigningKey_And_Origins → Disable_Clears_SigningKey_But_Preserves_Origins with assertion flip.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tur 2 of the post-v0.2.0 portal audit follow-up. Tur 1 (#101) closed the 3 P0 security findings; this PR closes the P0 test coverage gaps the same audit surfaced.
23 new tests, every one of them aimed at a security-load-bearing branch that previously had zero coverage.
What's covered
PortalEndpointsControllerTests(+5) — every mutating route is its own CAS surface:DELETE/enable/disable/test/attemptsagainst another tenant's endpoint id all return404 PORTAL_NOT_FOUND. Previously onlyGETandPUTwere guarded; the other routes were untested.EndpointTesterwas never invoked — proves the 404 precedes any outbound dispatch.403 PORTAL_INSUFFICIENT_CAPABILITY. Defense-in-depth against a future refactor that treats missing claim as wildcard.PortalCorsMiddlewareTests(new, +7) — was at literally zero coverage:Vary: Origin.https://acme.com.attacker.comagainsthttps://acme.comallowlist) → 403.PortalTokenAuth → PortalCors) in a minimal pipeline.PortalLookupCacheTests(new, +5):InvalidateApplicationforces DB reload on nextGetAsync.InvalidateApplicationdoesn't throwObjectDisposedException(regression for the Tur 1 audit fix whereSetused toGetOrAdd-reuse a disposed CTS).PortalOriginsAllowlistE2ETests(new, +7, Testcontainers PostgreSQL):AnyAllowsPortalOriginAsyncagainst real PostgreSQL JSONB.[Theory].JsonExceptioncatch isn't reachable through the EF write surface (PostgreSQL rejects invalid JSON atINSERT INTO jsonb).Test plan
dotnet build— 0 warnings, 0 errors.dotnet test— 278 / 278 passing (Core 34, API 121, Worker 46, Infrastructure 77).Follow-ups (tracked, not in this PR)
docs/API.md+docs/ARCHITECTURE.mdportal sections).DOCKERHUB_TOKENscope fix + comment cleanup.